E-쿠버네티스 인증 실습
개요
쿠버네티스의 시큐리티에서 중요한 부분 중 하나인 인증.
인증 방법이 여러 개 있다는 것은 알았으니, 이제 진짜 인증을 실습해본다.
일단 크게 5가지가 있다.
계획
인증에 대해서만 실습을 하는 것이 목적이다.
여러 인증 방법을 동시에 적용하고, 동적으로 관리하는 것을 목표로 잡는다.
이를 위해서, 인가에 대해서는 완전히 자유로운 서비스 어카운트와 유저 어카운트를 사용할 것이다.
- 인증서 파일을 통한 인증
- 정적 토큰 파일
- 서비스 어카운트 토큰
- 토큰 요청과, 정적 토큰 두 가지 중 하나를 해볼 것이다.
- OIDC 인증
- keycloak?
- 웹훅 인증
- 직접 하나 만들어보기
- 인증 프록시
- 이건 실질적으로 인증서 파일을 통한 통신에서 헤더만 추가시킨 것과 다를 게 없다.
부트스트랩 인증은 어떻게 할까?
이것에 대해서는 분석만 해보는 정도로 끝내고자 한다.
환경 세팅
어카운트 세팅
먼저, 자유롭게 활용할 수 있는 유저와 서비스 어카운트가 필요하다.
이 각각에 대해서 각 방법을 쓸 수 있는지 보자.
apiVersion: v1
kind: ServiceAccount
metadata:
name: test-sa
---
apiVersion: rbac.authorization.k8s.io/v1
kind: RoleBinding
metadata:
name: sa-edit
roleRef:
apiGroup: rbac.authorization.k8s.io
kind: ClusterRole
name: edit
subjects:
- kind: ServiceAccount
name: test-sa
namespace: default
---
apiVersion: rbac.authorization.k8s.io/v1
kind: RoleBinding
metadata:
name: ua-edit
roleRef:
apiGroup: rbac.authorization.k8s.io
kind: ClusterRole
name: edit
subjects:
- kind: User
name: test-ua
namespace: default
이렇게, 디폴트 네임스페이스서 편집 권한을 가지는 test-sa 서비스 어카운트를 만들었다.
유저에 대한 정보는 쿠버네티스에서 따로 정리하여 관리하는 게 없다.
그렇기에 각종 인증 방법을 통해 해당 유저를 인증하는 방식을 택해야 하는데, 미리 인가 권한은 만들어둘 수 있다.
흉내를 통해 각각 정확하게 권한을 가지고 있는지 확인한다.
감사 세팅
E-api 서버 감사에서 기본적인 감사를 진행한다.
내가 테스트에 활용하고자 하는 계정들에 대해서만 감사가 되도록 할 것이다.
그렇지 않으면 지나치게 많은 데이터가 쌓일 여지가 있다..
X509 인증서 파일을 통한 인증
X509는 디지털 인증서의 표준 형식을 말한다.
--client-ca-file
인자에 들어가는 파일은 루트CA 인증서 파일이다.
이걸 어떻게 하냐?
그냥 TLS 인증서 만들고 이걸로 통신을 하면 된다.
문서로 어떻게 인증서를 생성할 수 있는지 나와있다.[1]
CN에 해당 유저, O(조직)에 그룹이 들어가게 해주면 된다.
여기에서는 cfssl을 사용해 인증서를 받아보겠다.
이전에 openssl을 사용하며 받았던 고통으로부터 해방될 수 있을지 함 보자.
root CA 만들고, 클러스터에 등록하기
이전에는 클러스터가 구성될 때 만들어진 root ca를 썼지만, 이번에는 내 자체 root ca를 만들고 이를 신뢰하게 헤보겠다.
차후에 클러스터에서 지원하는 CSR api와 직접적인 비교를 할 수 있게 하기 위함이다.
cfssl print-defaults config
cfssl print-defaults csr
일단 간단하게 양식 템플릿을 꺼내서 수정한다.
{
"signing": {
"default": {
"expiry": "16800h"
},
"profiles": {
"zerotay-ca": {
"usages": [
"signing",
"key encipherment",
"server auth",
"client auth"
]
}
}
}
}
먼저 config 파일은 이렇게 넣었다.
{
"CN": "zerotay-ca",
"key": {
"algo": "rsa",
"size": 2048
},
"names": [
{
"C": "KR",
"ST": "Seoul",
"L": "Seoul",
"O": "test"
}
]
}
다음은 csr json 파일이다.
매우 간단하게 루트 인증서가 발급된다.
openssl이었으면... 일단 명령어를 작성하는 것부터가 매우 귀찮은데 이거 괜찮네?
매우 간단하게 CA키와 인증서가 생긴 것을 볼 수 있다.
헛.. 검증도 너무 쉽다.
그러니까, openssl의 단점은 명령어를 쓰는 방식이 자유롭고 일관성이 부족하다는 것, 그리고 번거롭게 쓸 게 많다는 것이었다.
그런데 이놈은 확실히 이런 것들이 편하다.
개인키를 만들고, CSR을 만든 후에, 인증서를 요청하던 귀찮은 과정이 전부 사라졌다.(openssl도 CSR과 개인키를 동시 뽑을 수 있긴 하다.)
그냥 csr 설정 파일만 써주면 모든 파일이 한꺼번에 생성된다.
아무튼 이제 인증서를 클러스터에 넣어주도록 하자.
api서버의 인자로 들어가는 --client-ca-file
에 내가 만든 인증서를 묶음으로 만들어 포함시키는 것이다.
기존에 있던 루트 인증서에, 내가 만든 인증서까지 포함하여 진행해본다.
이건 그냥 사실 한 파일에 여러 개를 나열하면 되는 거라 어려운 게 아니다..
두번째는 그냥 api 서버가 쓰는 노드의 CA 디렉에 정보를 넣어주는 것이다.
전자보다는 후자가 조금 더 궁금해서 시도해본다.
일단 간단하게 넣어봤는데, 확장자는 반드시 crt로 해줘야 제대로 반영이 된다고 한다.
먼저 노드 로컬에서는 정상적으로 작동되는지 확인해봤다.
이거 한번 넣기만 하면 이후에는 파일을 옮긴다고 삭제되는 구조는 아니다.
실제 파일들은 /etc/ssl/certs
에 심볼릭 링크로 연결되는 구조이다.
update-ca-certificate --fresh
인증서를 없앤 걸 반영하고 싶다면 이렇게 해주면 된다.
api서버가 동적으로 ca 인증서를 반영한다는 글을 찾아본 적이 없으므로, api 서버를 재시작시킬 것이다.
그러나 아래 개인 인증서로 통신을 시도해볼 때 확인하면서 진행하자.
개인 인증서 발급
{
"CN": "test-ua",
"hosts": [ ],
"key": {
"algo": "rsa",
"size": 2048
},
"names": [{
"O": "test-group"
}]
}
유저의 이름과 그룹은 CN과 O로 결정된다.
json은 다음 원소가 없을 때 콤마를 넣는 것을 제한하니 염두하여 작성하자.
cfssl gencert -ca ca.pem -ca-key ca-key.pem -config ca-config.json -profile zerotay-ca user-csr.json
뭔가 길게 썼는데, 정말 별게 아니다.
그냥 cfssl gencert -h
쳐서 보고 나서 그대로 따라 쳤을 뿐이다.
테스트
def request_in_x509():
headers = {
"Content-Type": "application/json",
}
user_crt = curr_dir + "/x509/ua.pem"
user_key = curr_dir + "/x509/ua-key.pem"
response = requests.get(
url= url,
headers=headers,
verify= curr_dir+"/ca.crt",
cert=(user_crt, user_key),
)
print(response.status_code)
print('Response Header :::::')
print(json.dumps(dict(response.headers), indent=2))
print('Response Body :::::')
print(json.dumps(response.json(), indent=2))
테스트하는 코드는 이렇게 짰다.
아래 사진들은 이렇게 구조화하기 이전에 출력했던 값들이다.
아무런 인증 없이, 그냥 나를 넣었을 때는 이렇게 401이 반환된다.
그러나 루트 인증서를 api서버에 넣어주고 난 후에는 정상적으로 값이 돌아오는 것이 확인된다.
CN에 넣었던 값이 유저 이름, O에 넣은 값이 그대로 그룹에 들어갔다.
인증이 완료되었기 때문에 system:authenticated
그룹에도 포함되었다.
인증되지 않은 시점의 요청도 감사에 기록될 거라 생각했다.
그런데 내 의도대로 인증되지 않은 요청이 기록되지 않았다.
아무래도 정책을 잘못 설정한 게 아닐까 싶다.
범위를 넓혀보니까 확실해졌다.
인증을 실패한 유저는 유저 필드에 아무런 내용이 기록되지 않기 때문에 감사가 이뤄지지 않은 것이다.
인증되지 않은 유저에 대해서는 단순하게 401을 내뱉는 것을 확인할 수 있다.
그래서 정책은 이런 식으로 수정했다.
정적 토큰 파일
이건 토큰을 이용하는 방식이다.
그러나 이 토큰은 말 그대로 정적으로, api 서버를 기동시킬 때 한번만 설정된다.
즉, 추가적으로 유저를 늘리거나 하고 싶다면 api 서버를 무조건 재기동을 시켜야 한다.
또한 한번 인증된 유저는 계속 인증된다.
여러 모로, 책임 관리자만이 간혹 활용할 수 있도록 하는 것이 좋을 것이다.
token,user,uid,"group1,group2,group3"
단순하게 csv 파일을 만들고, 이것은 api서버 --token-auth-file
로 넣어주면 된다.
이렇게 넣어줬다.
zerotay,test-ua,5123,"test-group"
파일은 이렇게 세팅했다.
그럼 요청을 날릴 때는 어떻게 하는가?
Authorization: Bearer <토큰>
헤더를 넣어주면 된다.
테스트
def request_in_static():
token = "zerotay"
headers = {
"Content-Type": "application/json",
"Authorization" : "Bearer " + token
}
response = requests.get(
url= url,
headers=headers,
verify= curr_dir+"/ca.crt",
)
return response
이번에는 토큰 이름은 zerotay이고 이렇게 헤더에 넣어서 보내주면 된다.
이렇게 uid 5123의 유저가 들어온 것으로 간주된다.
역시 안전하지 않은 방법이라는 생각이 든다.
한번이라도 토큰이 탈취되는 순간 대참사가 발생할 것이다.
서비스 어카운트 토큰
이번에는 유저 계정이 아닌, 서비스 어카운트의 계정으로 접속을 시도해본다.
이제부터는 웬만해서 전부 Authorization
헤더에 정보를 넣는 방식으로 이뤄진다.
토큰 생성
서비스 어카운트를 위한 토큰을 만들어준다.
이것은 JWT 토큰으로, [[#정적 토큰 파일]]처럼 넣어주면 된다.
테스트
코드는 위와 동일하다.
그러나 문제가 또 생긴 것이, 다른 서비스 어카운트의 이벤트를 보지 않기 위해 무심코 정책으로 서아 그룹을 무시하게 해버려서 이벤트에 뜨지 않는다는 것이다.
그래서 이걸 다시 세팅해야 한다.
한 서비스 어카운트에 대한 이벤트만 남기도록 재수정을 해야 한다.
- level: None
users: ["system:kube-proxy", "system:apiserver"]
namespaces: ["default"]
- level: Request
users: ["system:serviceaccount:default:test-sa"]
namespaces: ["default"]
- level: None
userGroups: ["system:nodes", "system:masters", "system:serviceaccounts"]
namespaces: ["default"]
- level: Request
#users: ["test-ua", "test-sa", "kubernetes-admin"]
namespaces: ["default"]
규칙은 이렇게 설정했다.
내가 원하는 서비스 어카운트는 일단 기록되고, 나머지는 기록되지 않을 것이다.
제대로 오도록 만들었다!
OIDC 토큰
OIDC를 사용하는 것은 클러스터에서 발생할 수 있는 운영 이슈와 별개로 인증 툴을 마련할 수 있다는 점에서 장점이 있다.
뭐 이걸 또 굳이 클러스터 내부에 구축해서 운영하는 케이스라면 이런 장점을 누릴 수 없다.
대신 이런 케이스는 아무래도 인증 관리를 별도의 서비스로 구축하는 것이니 중앙 집중형 관리, SSO 측면에서 관리 편의성을 증가시킬 수 있긴 하겠다.
해당 서버로부터 3개의 토큰을 받고, 이 중 id_token
을 인증에 활용한다.
일단 api 서버 측에서는 두 가지 방법으로 설정할 수 있다.
인자로 oidc 정보를 넘기거나, 아니면 설정 파일을 만드는 것이다.
--oidc-issuer-url
--oidc-client-id
이 둘을 간단하게 비교해볼 것이다.
그럼 무슨 OIDC 프로바이더를 활용해야 하는가?
여기에서도 두 가지 방법으로, 하나는 클라우드 공급자, 그리고 하나는 오픈소스로 로컬에 구축해서 실습해볼 것이다.
일단 먼저 OIDC 공급자 세팅을 해보자.
AWS OIDC(Cognito)
과거에 GCP로 서비스 계정을 만들어본 경험은 있긴 하지만, 최근 더 많이 만져본 것은 4.RESOURCE/KNOWLEDGE/AWS/AWS이므로 간단하게 aws를 활용해보기로 마음 먹었다.
Amazon Cognito를 활용한다.[2]
이것 말고도, AWS Identity and Access Management을 프로바이더로 쓰는 방법도 있긴 하다는 것 같다.[3]
정확하게는 아직 모르겠으나, 이건 rbac와 더불어서 인가용으로 쓰는 것이 주된 용도라고 해석했기에 시도하지 않는다.
금액적인 고민은 하지 않고 생각한 건데, 간단하게 금액도 생각은 해봐야겠다.
먼저 유저 풀을 만들어준다.
이때 이 유저 풀을 사용할 어플리케이션 타입을 지정해 설정을 조금 더 간단하게 할 수 있다.
근데 나중에 알게 된 것이지만, 여기에서 M2M을 선택하면 access token만 나오게 된다.
애초에 사용자를 인증하는 것이 아니라 특정 앱에 접근 권한만 주는 것을 목표로 하기 때문이다.
이제 앱클라이언트 설정을 확인하고, 비밀번호를 통해서도 접근이 가능하도록 세팅해줘야 한다.
이렇게 하는 이유는 간단하게 로그인 페이지나 별도의 챌린지(인증을 위한)의 복잡성을 줄이고 유저의 id토큰을 가져오기 위함이다.
당연히 프로덕션 레벨에서는 좋은 방식이 아닐 것이고 더 엄격한 인증 전략을 마련할 것이 추천된다.
다음으로는 해당 유저 풀에 유저를 만들고, confirmed 상태로 만드는 작업이다.
단순하게 콘솔로 유저를 만들면, 아래처럼 force change password 상태로 되어있다.
이걸 confirmed 상태가 되게 해야 하며, 이를 위해서는 추가적인 세팅이 필요하다.
일단 그냥 requests로 하고, 이후에 boto3로 간략하게 하는 방법을 알아보려고 했다.
그러나, 생각 이상으로 이 방법은 인증을 위한 암호화 서명을 하는데 상당한 지식이 필요했고, 당장의 실습에 추가적인 시간을 너무 소모하게 만든다고 판단하여 그냥 boto3으로만 진행하는 것으로 노선을 수정했다.[4]
나중에 조금 더 지식이 쌓이면 다시 도전해보던가 해보자.
아래는 지피티의 도움을 받으면서 시도했던 코드이다.
import requests
import datetime
import hashlib
import hmac
import json
# AWS Cognito 설정
aws_access_key = ''
aws_secret_key = ''
region = 'us-east-1' # 예시: us-east-1
user_pool_id = 'us-east-1_F8jtObheG' # 유저 풀 ID
username = 'zerogun1000'
# AWS 요청 서명 생성 함수
def sign_request(key, msg):
return hmac.new(key, msg.encode('utf-8'), hashlib.sha256).digest()
def get_signature_key(secret_key, date_stamp, region_name, service_name):
k_date = sign_request(("AWS4" + secret_key).encode('utf-8'), date_stamp)
k_region = sign_request(k_date, region_name)
k_service = sign_request(k_region, service_name)
k_signing = sign_request(k_service, "aws4_request")
return k_signing
# 날짜 및 서명 준비
service = 'cognito-idp'
method = 'POST'
host = f'cognito-idp.{region}.amazonaws.com'
endpoint = f'https://{host}/'
content_type = 'application/x-amz-json-1.1'
target = 'AWSCognitoIdentityProviderService.AdminConfirmSignUp'
amz_date = datetime.datetime.now(datetime.timezone.utc).strftime('%Y%m%dT%H%M%SZ')
date_stamp = datetime.datetime.now(datetime.timezone.utc).strftime('%Y%m%d')
# 요청 페이로드
payload = {
"UserPoolId": user_pool_id,
"Username": username
}
payload_json = json.dumps(payload)
# Canonical request 생성
canonical_request = f"{method}\n/\n\nhost:{host}\ncontent-type:{content_type}\nx-amz-date:{amz_date}\nx-amz-target:{target}\n\nhost:content-type:x-amz-date:x-amz-target\n{hashlib.sha256(payload_json.encode('utf-8')).hexdigest()}"
# String to Sign 생성
algorithm = 'AWS4-HMAC-SHA256'
credential_scope = f"{date_stamp}/{region}/{service}/aws4_request"
string_to_sign = f"{algorithm}\n{amz_date}\n{credential_scope}\n{hashlib.sha256(canonical_request.encode('utf-8')).hexdigest()}"
# 서명 키 생성 및 서명 생성
signing_key = get_signature_key(aws_secret_key, date_stamp, region, service)
signature = hmac.new(signing_key, string_to_sign.encode('utf-8'), hashlib.sha256).hexdigest()
# Authorization 헤더 생성
authorization_header = (f"{algorithm} Credential={aws_access_key}/{credential_scope}, "
f"SignedHeaders=host:content-type:x-amz-date:x-amz-target, Signature={signature}")
# 요청 헤더
headers = {
'Content-Type': content_type,
'X-Amz-Date': amz_date,
'X-Amz-Target': target,
'Authorization': authorization_header
}
# 요청 전송
response = requests.post(endpoint, data=payload_json, headers=headers)
# 응답 확인
print(response.status_code)
print(response.json())
boto3를 사용해, 관리자로서 유저의 비밀번호를 세팅했다.
Permanent 옵션을 주면 간단하게 자동으로 confirmed 상태가 된다.
그리고 추가적으로 유저 속성을 하나 추가해줬다.
그래야 나중에 id토큰의 claim에 해당값이 들어가게 된다.
IdToken 받기
response = requests.post(
'https://us-east-1f8jtobheg.auth.us-east-1.amazoncognito.com/oauth2/token',
data=data,
headers={'Content-Type': 'application/x-www-form-urlencoded'}
)
원래는 이런 식으로 해서 토큰을 요청할 생각이었다.
data = {
"grant_type": "password",
"client_id": "mlad9fi16j8t5q9drbge2j6r",
"username": "",
"password": "",
"scope": "openid"
}
grant_type은 password로, 아마존 콘솔에서 이를 미리 설정했다.
그러나 막상 해보니 계속 invalid client type이라는 응답이 돌아왔다.
그래서 client_secret을 바디에 포함시켜서 보내봤으나, 이번에는 unsupported grant type이라는 응답만이 돌아왔다.
다른 방법을 찾아보았고, 다음의 방법으로 성공할 수 있었다.
이것은 아마존 api를 직접적으로 사용하는 방식이며, 관련 글이 있어서 오히려 그나마 쉽게 따라할 수 있었다.[5]
이게 좋은 게, cognito 문서를 직접 찾아봐도 이런 방식의 사용법에 대한 정리가 조금 더 돼있는 편이다.
`PASSWORD`: Respond with `USER_PASSWORD_AUTH` parameters: `USERNAME` (required), `PASSWORD` (required), `SECRET_HASH` (required if the app client is configured with a client secret)
이런 문구가 있다.
즉, secret hash를 같이 넣어줘야 한다.
이것은 토큰을 받으려는 주체가 정말 앱 클라이언트의 정보를 제대로 알고 있는지 확인하는 해시값으로, client secret 정보를 활용한다.
def calculate_secret_hash(client_id, client_secret, username):
message = username + client_id
dig = hmac.new(client_secret.encode('utf-8'),
message.encode('utf-8'),
hashlib.sha256).digest()
return base64.b64encode(dig).decode()
이런 식으로 해시를 만들면 된다.
익숙하지 않아서 조금 더 설명을 붙여보자면, HMAC(Hash-based Message Authentication Code)를 이용해 해시값을 생성한다.
new를 통해 해시를 생성하는데, 첫 인자인 client_secret를 키로 사용하여, 다음 인자인 message를 sha256 함수를 사용하여 해시를 꺼낸다.
digest는 이를 바이트 형식으로 변환하는 것인데, 이를 base64로 인코딩해주고, 최종적으로 str 타입으로 바꿔준다.
인코디코딩을 왜 하는 건가 했는데, type 변환을 해주더라.
아무튼 이렇게 client_secret으로 해시를 만든다는 것은 해당 요청을 보내는 주체가 client_secret을 알고 있다는 것을 보장해준다.
# This is Amazone api url
url = "https://cognito-idp.us-east-1.amazonaws.com/"
client_id = ""
client_secret = ""
username = ""
passworkd = ""
# SECRET_HASH has to be made, if needed
secret_hash = calculate_secret_hash(client_id, client_secret, username)
headers = {
'Content-Type': 'application/x-amz-json-1.1',
'X-Amz-Target': 'AWSCognitoIdentityProviderService.InitiateAuth'
}
data = {
"AuthFlow": 'USER_PASSWORD_AUTH',
"ClientId": client_id,
"AuthParameters": {
"USERNAME": username,
"PASSWORD": password,
"SECRET_HASH": secret_hash
}
}
이런 식으로 헤더와 바디를 구성한다.[6]
이제 요청을 보내면 이런 식으로 값이 온다!
값을 보면 이렇게, 설정이 된 것이 확인된다.
그러고보니까 내 맘대로 설정한 유저의 토큰을 받았네.. test-ua 유저를 만들고 다시 진행해본다.
api 서버 세팅
간단하게 api 서버에서 세팅을 할 때는 그냥 인자로 넣어서 주는 방법을 한번 써본다.
--oidc-issuer-url=https://아마존 콘솔가면 예시 코드에 issuerURL 있음
--oidc-client-id=앱 클라
#--oidc-username-claim=username
#--oidc-groups-claim=groups
#--oidc-ca-file=/etc/ssl/certs/ca-certificates.crt
기본적으로는 두 가지만 넣어줘도 충분하다.
간단하게 세팅하고 진행하니 유저가 이상하게 나온다.
인자에 --oidc-username-claim
으로 클레임의 어떤 값을 유저 이름으로 쓸지를 지정해줘야 한다.
조금 접두사가 무엇이 붙는지도 정확하게 캐치해야 한다.
이 접두사는 api 서버가 oidc로 인증된 유저라는 것을 명확히 나타내기 위해 알아서 붙이는 값이다.
username은 무조건 기본으로 붙는 값이 있어서 주의가 필요하다.
--oidc-username-prefix=-
라고 하면 접두사는 비활성화된다.
요로코롬 성공했다!
- --oidc-issuer-url=값
- --oidc-client-id=값
- --oidc-username-claim=name
- --oidc-username-prefix=-
- --oidc-groups-claim=cognito:groups
그룹 정보도 들어갈 수 있도록 이렇게 최종적으로 설정했다.
groups 같은 경우는 기본으로 붙는 접두사가 없어서, 접두사를 안 붙이길 바란다면 그냥 명시하지 않으면 된다.
configuration 파일 설정하기
이렇게 api 서버의 인자로 넣는 것도 가능하지만, 아예 설정 파일로 만들어서 경로를 명시해주면 더 편리하게 많은 인증 모듈을 관리할 수 있게 된다.
또한 좋은 점은 파일의 변경 사항을 api서버에서 추적하여 알아서 업데이트를 해준다는 것이다!
여러 개의 인증 모듈을 넣고 싶고, 세부적으로 규칙을 지정하거나 변경이 용이하도록 할 때는 꼭 설정 파일을 만들도록 하자.
--authentication-config
로 파일 경로를명시해주면 된다.
apiVersion: apiserver.config.k8s.io/v1beta1
kind: AuthenticationConfiguration
jwt:
- issuer:
url: 발행자 url
# 청중은 JWT가 반드시 발급 받아야 하는 값이다.
# JWT 클레임의 aud가 반드시 이 값에 있어야 해당 토큰이 유효하다고 판단될 것이다.
audiences:
- aws는 그냥 앱 클라 id
audienceMatchPolicy: MatchAny
# 유저를 인증하기 위해 토큰 클레임을 검증할 때 적용되는 정책. 없어도 되긴 함
claimValidationRules:
- expression: '"amazon" in claims.iss.lower()'
message: Only Amazone expected for the issuer
# 클레임 매핑
claimMappings:
username:
claim: "claims.name"
prefix: ""
groups:
expression: 'claims["cognito:groups"]'
# 최종 유저 객체에 적용되는 검증 규칙.
userValidationRules:
- expression: "!user.username.startsWith('system:')"
message: 'username cannot used reserved system: prefix'
- expression: "user.groups.all(group, !group.startsWith('system:'))"
message: 'groups cannot used reserved system: prefix'
jwt 하위 필드로 여러 개가 가능하기에, 여러 인증 모듈을 쑤셔박을 수 있다!
간단하게 작성 방식을 말하자면, 일단 어떤 발행자인지를 명시한다.
이후 들어온 jwt 토큰이 적합한지 검증하기 위해 claimValidation을 지정한다.
이게 통과된 후, 해당 유저를 식별하기 위한 값들을 jwt 토큰에서 매핑하기 위해 claimMappings을 설정한다.
이때 jwt 클레임 파트의 값은 claims. 라는 식으로 접근할 수 있고, 이를 통해 원하는 정보를 매핑해야 한다.
마지막으로 해당 유저가 인증되어도 괜찮은지 추가적인 조건을 적어준다.
이런 식으로 유저가 잘 들어온 것이 확인된다.
정말 편한 게 api 서버를 재가동시키지 않아도 알아서 파일의 변경사항을 읽어준다!
라고만 생각했는데, 런타임 중에 파일 형식을 잘못 만들어서 올리면 해당 파일을 읽지 않는 듯하다.
변경될 거라고만 생각하고 기다리고 있었는데 계속 안 되길래 api 서버를 재시작해보고 나서야 알았다..
변경이 아주 빠르게 반영되지는 않는다는 것은 참고하자.
파일에 변경사항이 생기면 api서버 로그로는 파일을 찾을 수 없다고 나온 뒤에, 한참 후에(대략 1분은 되는 듯) 재설정이 되는 것을 확인할 수 있었다.
유효, 검증 테스트
상세 설정을 하는 김에 몇가지 테스트를 진행했다.
먼저 클레임 유효 검증을 하는 부분이다.
api 서버에 이렇게 로그가 남게 된다.
새삼 드는 생각이, 이건 audit 로그로 남지 않는 것 같은데 디버깅하는데 까다로움이 있을 수 있겠다.
이걸 audit 로그와 함께 보는 방법은 없는 것인가?
어떤 요쳥이 유효하지 않다고 했을 때, 유효하지 않은 이유를 관리자가 하나의 스트림으로 명확하게 파악알 수 있다면 큰 도움이 될 것이다.
이건 로깅과 관련된 내용인 것 같은데, 보안 부분 공부를 마치면 이쪽을 파봐야겠다.
다음은 유저 유효 검증이다.
oidc: error evaluating user info validation rule: validation expression '!user.username.contains('test')' failed: My user has \"test\" in name]"
이 경우 이렇게 에러가 뜨게 된다.
KeyCloak 활용
오픈소스 인증 관리 툴에는 다양한 것이 있다.
문서에 나와있는 것만 해도 dex, uaa, openunison이라는 것들도 있는데, 나는 가장 먼저 이름을 들어보았던 Keycloak을 실습해보고자 한다.
세팅 방법은 Keycloak#사용법에 적었다.
여기에서는 위에서 삽질한 것들은 최대한 줄이고 진행한다.
IdToken 받기
일단 대충 받아내는데에는 성공했다.
api 서버 세팅 후 테스트
이번에는 설정파일만 이용하여 진행해본다.
잠시 주석 처리를 했지만, 키클록의 CA가 내 맘대로 만든 놈이라 저런 식으로 넣어줘야 한다고 한다.
oidc는 무조건 https가 강요되어서 추가적인 세팅을 곁들여야만 했다.
앞단에 https 설정을 해주는 방법도 있다고 생각했다.
Gateway API, Ingress를 쓰는 방법이 있지 않을까.
그래서 이를 시도했는데, Keycloak에서 보듯이 해당 방법은 키클록이 계속 http로 리디렉션을 시키는 바람에 포기했다.
X-Forwarded-Proto
를 넣어줘도 키클록 어플리케이션이 계속 http로 페이지를 리디렉션시켜서 의도하지 않는 결과가 나왔다.
결국 키클록을 다시 세팅하게 됐다.
여기에 추가적인 문제도 존재했는데, 왜인지 갑자기 호스트 노드에서 외부 ip로의 통신 요청이 제대로 파드에 전달되지 않았다.
iptables에 어떤 이슈가 발생했거나, 혹은 해당 노드에서 제대로 트래픽을 처리하지 않는 것으로 보이는데, 정확한 이유를 모르겠다.
일단은 노드포트로 해결을 보았다.
현재 마주치고 있는 이슈는 이것이다.
정확하게 Id token을 넣어주었고, jwt 형식이 제대로 된 것도 확인했다.
한 가지 다른 게 보인다면, 헤더에 typ
이란 필드가 더 들어간다는 것이다.
다른 가설을 하나 더 세웠다.
클레임에 들어가는 iss
필드가 명확하게 들어오는 검증하는 url과 일치하게 해야 한다.
그 검증이 설정 파일의 url
필드를 통해 이뤄진다.
또한 이 필드를 통해 discovery도 진행하는데, 이때 따로 discoveryURL을 명시해주면 해당 경로로 탐색을 진행한다고 한다.
음. 이건 실패.
그러나 현재 이슈는 아무래도 iss가 달라서 생기는 이슈가 맞을 것이라고 생각이 든다.
이렇게 된다면 호스트 노드의 포트를 조작하는 iptables 직접 쓰던가, 해야 한다.
여기에선 나는 잠시 꼼수를 부리고자 한다.
kubectl의 포트 포워딩 기능이다.
kubectl -n keycloak port-forward svc/keycloak 443:443 --address 192.168.80.11
참고로, localhost는 컨테이너 네트워크 네임스페이스로 격리돼있기에 호스트 네트워크 모드로 기동되는 파드여도 활용할 수 없는 듯하다.
그래서 정확하게 노드 ip를 기입했다.
간단하게 /etc/hosts
를 수정해주면서 해당 도메인을 포트포워딩된 443 포트로 연결할 수 있게 만들었다.
이때 /etc/hosts 파일의 변경사항을 api서버가 동적으로 추적하지는 않기 때문에 api서버를 통째로 재시작시켜줘야 한다.
드디어, 최소한 토큰 내용을 받아보기라도 하는 단계에 왔다.
으음.. 단순한 오타 이슈였던 것으로.
하하.. 드디어 성공이다.
키클록 세팅하면서 많은 고난을 겪었다..
스토리지 드라이버 문제도 발견하게 됐고, coredns 이슈도 찾았고, 트래픽 이슈가 계속 생겨서 디버깅한답시고 시간을 참 많이 먹었다..
힘든 일이 될 거란 건 알고 있었다.
이렇게 오래 걸릴 줄은 몰랐지만..
하지만 다양한 지식을 얻을 수 있는 기회가 된 것 같다.
- oidc 설정 방법
- 이건 진짜 많이 공부하게 됐다..
- aws 코그니토 사용방법
- 유저 풀 정도만
- gateway api 세팅 방법
- kong 사용법
- keycloak 세팅 방법
- 호스트와 vm 네트워크 연결
- coredns 로깅 및 이슈 대처
각종 툴 사용에 있어서 깊이가 있었다고는 못 하겠다만, 관련한 이슈를 대처하는 경험을 쌓은 게 큰 의미라고 생각한다.
조금 고민인 사항은, 그래서 이 리소스를 정리할 것인가.
키클록은 당분간은 남겨두는 것도 좋을 것 같다.
나중에 인가 관련 실습을 할 때도 사용해야 하기 때문이다.
웹훅 토큰
거의 다 왔다.
웹훅은 일단 같은 방식으로 토큰을 받은 후, 이 토큰을 그냥 웹훅으로 외부 서비스에 날려서 인증을 맡기는 방식이다.
api서버에 다음의 인자를 설정할 수 있다.
--authentication-token-webhook-config-file
- kubeconfig 형식의 파일
--authentication-token-webhook-cache-ttl
- 캐시 ttl
--authentication-token-webhook-version
- 토큰 리뷰 객체 버전
authentication.k8s.io/v1beta1
,authentication.k8s.io/v1
- 토큰 리뷰 객체 버전
apiVersion: v1
kind: Config
# remote service
clusters:
- name: auth-webhook
cluster:
certificate-authority: /etc/kubernetes/pki/ca.crt
server: https://webhook.com:8000/auth
# api server
users:
- name: api-server
user:
client-certificate: /etc/kubernetes/pki/apiserver.crt
client-key: /etc/kubernetes/pki/apiserver.key
current-context: auth@kubernetes
contexts:
- context:
cluster: auth-webhook
user: api-server
name: auth@kubernetes
설정은 E-api 서버 감사에서 설정한 것처럼 해주었다.
웹훅 서버에 전송되는 객체는 이런 모양을 하고 있다.
참고로 위의 토큰은 키클록에서 쓰던 idtoken을 그대로 넣어서 보냈다.
{
"apiVersion": "authentication.k8s.io/v1",
"kind": "TokenReview",
"spec": {
"token": "014fbff9a07c...",
# 선택적 필드, 청중 식별자 리스트이다.
# 인증을 하는데 있어 청중이 누군지가 중요한 서비스라면 이것을 사용하게 될 것이다.
# 이 값이 있으면 응답 때도 이 리스트에 해당하는 값이 들어가야 한다.
"audiences": ["https://myserver.example.com", "https://myserver.internal.example.com"]
}
}
구조는 대충 이렇게 돼있다.
{
"apiVersion": "authentication.k8s.io/v1",
"kind": "TokenReview",
"status": {
"authenticated": true,
"user": {
# 필수 필드
"username": "janedoe@example.com",
"uid": "42",
"groups": ["developers", "qa"],
# 로깅에 남게 되는 추가 정보. 다른데 쓰일 수도 있다.
"extra": {
"extrafield1": [
"extravalue1",
"extravalue2"
]
}
},
# 청중 필드가 왔을 때 돌려보내야 할 선택적 필드.
"audiences": ["https://myserver.example.com"]
}
}
여기에 대해서는 이런 구조로 응답하면 된다.
이 방식은 oidc와 마찬가지로 완벽하게 인증을 외부에 일임하는 구조이다.
내가 원하는 대로 데이터를 넣었더니 그대로 반영된 것이 보인다.
관련 문서
이름 | noteType | created |
---|---|---|
Amazon Cognito | knowledge | 2024-07-04 |
Authentication | knowledge | 2025-01-13 |
6W - PKI 구조, CSR 리소스를 통한 api 서버 조회 | published | 2025-03-15 |
6W - api 구조와 보안 1 - 인증 | published | 2025-03-15 |
6W - EKS api 서버 접근 보안 | published | 2025-03-16 |
E-쿠버네티스 인증 실습 | topic/explain | 2025-01-21 |
T-서비스 어카운트 토큰은 어떻게 인증되는가 | topic/temp | 2025-03-16 |
참고
https://kubernetes.io/docs/tasks/administer-cluster/certificates/ ↩︎
https://docs.aws.amazon.com/cognito/latest/developerguide/what-is-amazon-cognito.html ↩︎
https://docs.aws.amazon.com/ko_kr/IAM/latest/UserGuide/id_roles_providers_create_oidc.html ↩︎
https://docs.aws.amazon.com/IAM/latest/UserGuide/reference_sigv-create-signed-request.html ↩︎
https://docs.aws.amazon.com/cognito-user-identity-pools/latest/APIReference/API_InitiateAuth.html ↩︎